# koroFileHeader at Yungoal acer
# Create: 2021-05-27 10:50:40
# LastEdit: 2025-08-05 17:30:38
"""说明暂无"""
__author__ = '749B'

import argparse
import csv
import msrestazure.azure_cloud
from requests import Response
import sys
import textwrap
import time

from .client import Azure_Client
from .data_format import runcmd_parse_output

from utils.blueking import job_start, job_success, job_fail
from utils.env import init_environs
from utils.tools import remove_empty

from typing import Mapping, List, Optional, Union, Generator, Tuple, NamedTuple
# 只是因为函数的返回需要做类型判断
from azure.mgmt.resourcegraph.models._models_py3 import QueryResponse
_Value = Union[str, int, None]
_Check = Tuple[bool, str]

# Azure_Monitor_Report_Resource的数据结构
# Azure Monitor 请求指标的参数
_Params = NamedTuple('_Params', [('metricnames', str), ('timespan', str), ('aggregation', str), ('interval', str), ('resultType',str)])
# 一个指标可能有多个汇聚方法，对应的是表格中的一列
_Field = NamedTuple('_Field', [('fieldname', str), ('key', str), ('func', str)])
# 每个指标，可以有一个单位换算
_Unit = NamedTuple('_Unit', [('scale', int), ('units', List[str]), ('space', str)])
# 一直指标，包括3个数据：1.请求参数，2.字段，3.单位换算。字段会有多个，对应的是不同的汇聚方法
_Metric = NamedTuple('_Metric', [('params', _Params), ('fields', List[_Field]), ('unit', _Unit)])


class RuncmdTask(object):
    """每台VM的一次Run command，对应一个Task"""
    
    def __init__(
        self, 
        data: Mapping[str, _Value], 
        check_items: List[str],
        async_operation: Optional[str] = None,
        resp: Optional[Response] = None,
        ) -> None:
        self.data = data
        self.check_items = check_items
        self.async_operation = async_operation
        self.resp = resp

        self.start_time = time.time()

    def print_title(self) -> Tuple[str, str, str, str]:
        """返回需要打印的一行字符串：标题"""
        vm_info = ','.join(self.data.keys())
        check_items = ','.join(self.check_items)
        return  vm_info, check_items, 'ERR_code', 'ERR_message' 

    def print_row(
        self, 
        rm_tag: bool = True, 
        resp: Optional[Tuple[str, str, str]] = None,
        multline: bool = False,
        ) -> Tuple[str, Union[str, List[str]], str, str]:
        """返回需要打印记录: VM信息, 标准输出, 错误码, 错误信息
        标准输出，可能有带标签的格式，方便做合规性判断，打印时不需要标签
        错误信息格式不可控，可能不规整，需要放到最后输出
        rm_tag: 默认开启。如果有冒号，就只输出第一个冒号之后的部分。可以手动设置为False，输出全部
        resp: 测试用，跳过self.parse_resp()，直接提供解析后的结果
        multline: 多行模式。并且无视rm_tag。
        """
        data_str_fmt = [str(v) if v is not None else '' for v in self.data.values()]
        # 列表中如果有 None 会报异常
        vm_info = ','.join(data_str_fmt)
        if resp is None:
            std_out, err_code, err_message = self.parse_resp()
        else:
            # 这个分支是提供给测试用例使用的
            std_out, err_code, err_message = resp
        if std_out:
            service_status_list = std_out.strip().split('\n')
            if multline:
                std_out_fmt = service_status_list
            else:
                if rm_tag:
                    service_status_list = [ i.split(':', 1)[1] if ':' in i else i for i in service_status_list ]
                service_status_list.extend(['Inserted' for _ in self.check_items])
                std_out_fmt = ','.join(service_status_list[:len(self.check_items)])
        else:
            std_out_fmt = ','.join(['' for _ in self.check_items])
        err_message_fmt = err_message.__repr__() if err_message else err_message
        return vm_info, std_out_fmt, err_code, err_message_fmt

    def parse_resp(self) -> Tuple[Optional[str], str, str]:
        """解析 self.resp
        返回 (标准输出, 错误代码, 错误信息)
        """
        if not self.resp.text:
            return None, self.resp.status_code, "NoHttpResponse"
        resp_obj = self.resp.json()
        err = resp_obj.get('error')
        if err:
            err_code = err.get('code')
            err_message = err.get('message')
            if err_code and err_message:
                return None, err_code, err_message
            return None, "ErrorWithException", err
        status = resp_obj.get('status')
        if status.lower() == 'succeeded':
            try:
                output = resp_obj['properties']['output']['value']
            except KeyError as e:
                return None, "OutputKeyError", e
            std_out, std_err = runcmd_parse_output(output)
            if std_err:
                err_code = 'StdErr'
                err_message = std_err
            else:
                err_code = ''
                err_message = ''
            return std_out, err_code, err_message
        if status.lower() == 'inprogress':
            # 任务超时
            return None, "RunningTimeout", self.resp.text
        # 其他情况
        return None, self.resp.status_code, self.resp.text

    def __str__(self) -> None:
        return "{}".format(self.data)


class RuncmdAsyncJob(object):
    """一批VM执行一次Run command，对应一个Job"""

    def __init__(
        self, client, pool=50, timeout=300, interval=1,
        show_count=False, csvfile=None, check_mode=None, rm_tag=True, 
        multline=False
        ) -> None:
        self.client = client
        self.pool = pool
        self.timeout = timeout
        self.interval = interval
        self.show_count = show_count
        self.csvfile = csvfile
        self.check_mode = check_mode
        self.rm_tag = rm_tag
        # 多行模式，脚本输出的一行，对应一条记录
        self.multline = multline

        self.show_count_num = 0
        self.task_list = []
        
        self._file = None
        if self.csvfile:
            self._file = open(self.csvfile, 'w', encoding='utf-8')

    def print_line(self, task: RuncmdTask, is_compliance: Optional[str] = None) -> None:
        """按需打印序号，并且可以有逻辑可以判断打印标题了"""
        if self.show_count_num == 0:
            self.print_func(*task.print_title(), is_compliance)
        if self.multline:
            vm_info, std_out_fmt, err_code, err_message_fmt = task.print_row(self.rm_tag, multline=self.multline)
            if isinstance(std_out_fmt, list):
                # 多行模式，正常会返回列表，每个元素是一行，逐行处理
                for line in std_out_fmt:
                    self.print_func(vm_info, line, err_code, err_message_fmt, is_compliance)
            else:
                # 如果没有stdout，则仍按单行模式处理。这里需要再按非多行模式获取一次输出，保证表格的列能对齐
                self.print_func(*task.print_row(self.rm_tag), is_compliance)
        else:
            self.print_func(*task.print_row(self.rm_tag), is_compliance)

    def print_func(self, vm_info: str, check_items: str, err_code: str, err_message: str, is_compliance: Optional[str] = None):
        """输出数据的方法"""
        if is_compliance is None:
            output = ','.join([vm_info, check_items, err_code, err_message])
        else:
            if self.show_count_num == 0:
                output = ','.join([vm_info, check_items, 'is_compliance', 'compliance_msg', err_code, err_message])
            else:
                output = ','.join([vm_info, check_items, is_compliance, err_code, err_message])
        if self._file:
            self._file.write(output + '\n')
        if self.show_count:
            print("%3d" % self.show_count_num, end=',')
            self.show_count_num += 1
        if self.show_count_num == 0:
            self.show_count_num += 1
        print(output)

    def add_task(
        self, 
        data: Mapping[str, _Value], 
        check_items: List[str],
        script_lines: List[str],
        parameters: Optional[List[Mapping[str, str]]] = None,
        ) -> Optional[bool]:
        """
        返回 pool 是否满了，如果满了，需要调用 get_task_result 方法
        """
        is_linux = data.get('OSType').lower() == 'linux'
        resp = self.client.runcmd_run(
            data['subscriptionId'],
            data['resourceGroup'],
            data['vm_name'],
            script_lines,
            parameters=parameters,
            is_linux=is_linux,
        )

        # 期待的结果是202，这个是没有resp.text的。
        # 先处理其他情况
        if resp.text:
            task = RuncmdTask(data, check_items, resp=resp)
            if self.check_mode:
                # check_mode 时需要解析下结果，不能直接打印
                self.task_list.append(task)
            else:
                self.print_line(task)
            return False
            
        if resp.status_code == 202:
            async_operation = resp.headers.get('Azure-AsyncOperation')
            task = RuncmdTask(data, check_items, async_operation=async_operation)
            self.task_list.append(task)
            return len(self.task_list) >= self.pool

        print("Task请求返回的resp进入预料外的分支:", resp, data, file=sys.stderr)

    def get_task_result(self) -> bool:
        """从任务列表中取出任务执行结果:
        直接打印结果
        """
        rm_list = []
        while not rm_list and self.task_list:
            time.sleep(self.interval)
            for task in self.task_list:
                resp = self.client.runcmd_result(task.async_operation)
                status = resp.json().get('status')
                # 只有还在执行中，并且没超时的 task 继续保留
                if status and status.lower() == 'inprogress' and time.time() - task.start_time < self.timeout:
                    continue
                task.resp = resp
                self.print_line(task)
                rm_list.append(task)
        for task in rm_list:
            self.task_list.remove(task)
        return len(self.task_list)

    def yield_task_with_resp(self) -> Generator[RuncmdTask, None, None]:
        """从任务列表中取出任务执行结果:
        返回给主函数处理
        """
        rm_list = []
        while not rm_list and self.task_list:
            time.sleep(self.interval)
            for task in self.task_list:
                # 这里会进来已经有resp的task，比如vm关机，直接就返回结果了，不用再异步去取执行的结果
                # task.resp 要判断是否为None，不能用布尔值。因为requests.Response返回409的结果也是False
                if task.resp is None:
                    resp = self.client.runcmd_result(task.async_operation)
                    status = resp.json().get('status')
                    # 只有还在执行中，并且没超时的 task 继续保留
                    if status and status.lower() == 'inprogress' and time.time() - task.start_time < self.timeout:
                        continue
                    task.resp = resp
                yield task
                rm_list.append(task)
        for task in rm_list:
            self.task_list.remove(task)

    def close(self):
        """关闭打开的文件"""
        if self._file:
            self._file.close()


class RuncmdCheck(object):
    """通过RuncmdAsyncJob，进行一个特定的检查"""

    def __init__(
        self,
        argv: Optional[List[str]] = None,
        script_linux: Optional[str] = None,
        script_windows: Optional[str] = None,
        check_items: List[str] = [],
        ) -> None:
        self.argv = argv
        self.script_linux = script_linux
        self.script_windows = script_windows
        self.check_items = check_items

        # 命令行解析的对象
        self.parser = None
        # 解析后的命令行参数
        self.args = None
        self.envs = None
        self.client = None # type: Optional[Azure_Client]
        # 检查不合规的记录数
        self.fail_count = 0

    def run(self) -> None:
        # 初始化命令行参数
        argv = self.argv
        if argv is None:
            # 蓝鲸作业平台有BUG，重做的任务，空着的参数里都会被填充null
            argv = [arg for arg in sys.argv[1:] if arg != 'null' and not arg.endswith('=null')]
        self.args = self.init_argparse(argv)
        if not self.args.envfile:
            return job_fail("请提供存有用户认证信息的env文件的文件名")

        # 初始化环境变量
        self.envs = init_environs(self.args.envfile)
        if not self.envs:
            return job_fail("env文件解密错误")

        self.remove_dev_args()

        # 实例化客户端
        CLOUD = getattr(msrestazure.azure_cloud, self.envs['cloud_name'])
        self.client = Azure_Client(CLOUD)

        if self.args.subscription:
            sub_list = []
            for sub in self.args.subscription.split(','):
                # pylint: disable = no-member
                sub_id = self.client.get_subscriptionid_by_name(sub)
                if not sub_id:
                    return job_fail("订阅或订阅ID不存在:%s" % sub)
                sub_list.append(sub_id)
            self.args.subscription = ','.join(sub_list)

        base_query = self.vm_str_query()
        if self.args.show_query:
            print(base_query)  # 蓝鲸平台上，那么多内容放到下面的job_success里会返回错误。单独print出来。
            return job_success("已打印查询语句，程序结束。")

        # 检查虚拟机数量
        vm_count = self.vm_count(base_query)
        print("虚拟机数量:", vm_count)
        if not vm_count:
            return job_fail("未匹配到任何虚拟机，建议检查过滤语句。")
        if self.args.dev_vm_count_limit and vm_count > self.args.dev_vm_count_limit:
            return job_fail(f"虚拟机数量过多({vm_count}>{self.args.dev_vm_count_limit})，优化过滤语句")

        # 查询虚拟机信息
        vm_info = self.vm_info(base_query, self.args.dev_limit)

        # 异步执行任务
        job = RuncmdAsyncJob(
            self.client, self.args.pool, self.args.timeout, 
            show_count=self.args.show_count, csvfile=self.args.dev_csvfile, 
            check_mode=self.args.check_mode, rm_tag=not self.args.keep_tag
            )
        
        if self.args.check_mode:
            self.task_yield_resp(job, vm_info)
        else:
            self.task_print_result(job, vm_info)

    @classmethod
    def init_argparse(cls, args: Optional[List[str]] = None) -> argparse.Namespace:
        """初始化：命令行参数"""
        parser = argparse.ArgumentParser(description='SOP合规性检查')
        parser.add_argument('-e', '--envfile', help="存有用户认证信息的env文件的文件名")
        parser.add_argument('-s', '--subscription', help='限定订阅，订阅名称或订阅ID都可')
        parser.add_argument('-g', '--resource_group', help='限定资源组名称')
        parser.add_argument('--os_type', default='all', choices=['all', 'windows', 'linux'], help='限定操作系统')
        parser.add_argument('-f', '--filters', help='其他过滤条件，参考资源查询的where语句')
        parser.add_argument('--show_query', action='store_true', help='只打印KQL查询语句，检查查询语句问题')
        parser.add_argument('--limit', type=int, dest='dev_limit', help='调试用，过滤前几条记录执行任务')
        parser.add_argument('-t', '--timeout', type=int, default=300, help='超过多少时间后，不再继续等待命令结果')
        parser.add_argument('--pool', type=int, default=50, help='同时进行的任务的数量')
        parser.add_argument('--show_count', action='store_true', help='每行记录去打印序号')
        parser.add_argument('--vm_count_limit', type=int, dest='dev_vm_count_limit', help='调试用，如果机器数量太多，则停止执行')
        parser.add_argument('--csvfile', dest='dev_csvfile', help="仅限本地使用，输出一份到文件")
        parser.add_argument('--keep_tag', action='store_true', help='默认去掉stdout每行第一个冒号及之前的部分，保持默认就很好。这个参数可手动控制输出全部')

        check_group = parser.add_argument_group('Check Mode', '检查模式')
        check_group.add_argument('--check_mode', action='store_true', help="逐条分析返回的结果")
        check_group.add_argument('--print_all', action='store_true', help="合规的结果仍然打印")
        check_group.add_argument('--ignore_not_running', action='store_true', help="没有开机的虚拟机，返回合规")

        cls.init_argparse_hook(parser)
        return parser.parse_args(args)

    @staticmethod
    def init_argparse_hook(parser: argparse.ArgumentParser):
        """钩子：添加额外的命令行参数"""
        #more_option = parser.add_argument_group('More Option', '更多选项')
        #more_option.add_argument('--more_option', action='store_true', help="开启更多选项")
        return

    def remove_dev_args(self, key: str = 'blueking') -> None:
        """移除开发环境使用的命令行参数"""
        if not self.envs:
            return
        if self.envs['runtime_env'] == key:
            for key in self.args.__dict__.keys():
                if key.startswith('dev_'):
                    v = getattr(self.args, key)
                    if v:
                        print(f'{key}={v}: 不支持的命令行参数，已经忽略。', file=sys.stderr)
                        setattr(self.args, key, None)

    def vm_str_query(self) -> str:
        """生成查询语句"""
        strQuery = textwrap.dedent("""\
            Resources
            | where type == "microsoft.compute/virtualmachines"
            | extend 
                OSType = properties.storageProfile.osDisk.osType
            | project vm_name=name, resourceGroup, OSType, subscriptionId
            {filter_subscription_id}
            {filter_resource_group}
            {filter_ostype}
            {other_filters}
            | join kind=leftouter (
                Resourcecontainers
                | where type =~ "microsoft.resources/subscriptions"
                | project SubName=name, subscriptionId
            ) on subscriptionId
            | project-away subscriptionId1
            """).format(
                filter_subscription_id='| where subscriptionId in~ ({})'.format(
                    ','.join([ '"{}"'.format(sub) for sub in self.args.subscription.split(',') ])
                ) if self.args.subscription else '',
                filter_resource_group='| where resourceGroup in~ ({})'.format(
                    ','.join([ '"{}"'.format(rg) for rg in self.args.resource_group.split(',') ])
                ) if self.args.resource_group else '',
                filter_ostype=f'| where OSType =~ "{self.args.os_type}"' if self.args.os_type != 'all' else '',
                other_filters=f'| where {self.args.filters}' if self.args.filters else ''
            )
        # 去掉空白行
        strQuery = remove_empty(strQuery)
        return strQuery

    def vm_count(self, base_query: str) -> int:
        """返回虚拟机的数量"""
        strQuery = base_query + '| summarize count()\n'
        # 去掉空白行
        strQuery = remove_empty(strQuery)
        # pylint: disable = no-member
        res = self.client.resource_graph_query_default(
            strQuery,
            [sub.get("subscription_id") for sub in self.client.get_subscriptions()],
        )
        return res.data[0].get('count_')

    def vm_info(self, base_query: str, limit: Optional[int] = None) -> QueryResponse:
        """拉取所有的虚拟机"""
        strQuery = base_query + f'| limit {limit}\n' if limit else base_query 
        # 去掉空白行
        strQuery = remove_empty(strQuery)
        # pylint: disable = no-member
        res = self.client.resource_graph_query_default(
            strQuery,
            [sub.get("subscription_id") for sub in self.client.get_subscriptions()],
        )
        return res

    def task_print_result(self, job: RuncmdAsyncJob, vm_info: QueryResponse) -> None:
        """获取任务结果，打印
        RuncmdAsyncJob 类内置了打印结果的方法
        """
        for vm in vm_info.data:
            # 检查 OSType 不会有预料外的情况，预期只能是'linux'或'windows'
            vm_ostype = vm.get('OSType', '').lower()
            if vm_ostype not in ['linux', 'windows']:
                print("vm_ostype不匹配:", vm, file=sys.stderr)
                continue
            # 检查有提供对应平台脚本，没有的话，就跳过不添加任务
            script_lines = None
            if vm_ostype == 'linux' and self.script_linux:
                script_lines = self.script_linux.splitlines()
            if vm_ostype == 'windows' and self.script_windows:
                script_lines = self.script_windows.splitlines()
            if script_lines:
                pool_is_full = job.add_task(vm, self.check_items, script_lines)
                if pool_is_full:
                    job.get_task_result()
        # 最后取完所有的任务结果
        while job.get_task_result():
            pass

    def task_yield_resp(self, job: RuncmdAsyncJob, vm_info: QueryResponse) -> None:
        """获取任务结果，处理
        用 parse_resp(resp) 函数进行处理
        """
        
        def is_compliance(task: RuncmdTask) -> bool:
            """
            1. 检查任务结果是否合规，调用 self.parse_stdout(task)
            2. 打印任务的结果
            外面会做加法计数不合规的数量，取反后直接做加法
            """
            res, res_msg = self.parse_stdout(task)
            if res:
                if self.args.print_all:
                    job.print_line(task, ','.join([str(res), res_msg]))
                return True
            else:
                job.print_line(task, ','.join([str(res), res_msg]))
                return False

        for vm in vm_info.data:
            # 检查 OSType 不会有预料外的情况，预期只能是'linux'或'windows'
            vm_ostype = vm.get('OSType', '').lower()
            if vm_ostype not in ['linux', 'windows']:
                # 预料外的情况，应该只有 'linux', 'windows' 两种情况
                print("vm_ostype不匹配:", vm, file=sys.stderr)
                continue
            # 检查有提供对应平台脚本，没有的话，就跳过不添加任务
            script_lines = None
            if vm_ostype == 'linux' and self.script_linux:
                script_lines = self.script_linux.splitlines()
            if vm_ostype == 'windows' and self.script_windows:
                script_lines = self.script_windows.splitlines()
            if script_lines:
                pool_is_full = job.add_task(vm, self.check_items, script_lines)
                if pool_is_full:
                    for task in job.yield_task_with_resp():
                        self.fail_count += not is_compliance(task)
        # 最后取完所有的任务结果
        while job.task_list:
            for task in job.yield_task_with_resp():
                self.fail_count += not is_compliance(task)

    def parse_stdout(self, task: RuncmdTask) -> _Check:
        """获取任务结果后的处理函数
        返回是否合规
        """
        self.std_out, self.err_code, self.err_message = task.parse_resp()
        # VM 关机的情况
        if self.err_code == 'OperationNotAllowed' and self.err_message == 'The operation requires the VM to be running (or set to run).':
            return self.args.ignore_not_running, "关机"
        # 上一次 Run command 还在执行中
        if self.err_code == 'Conflict' and 'Run command extension execution is in progress.' in self.err_message:
            return False, "建议手动检查:有RunCommand还在执行中"
        if not self.std_out:
            if self.err_code == 'RunningTimeout':
                return False, "请手动检查:执行脚本超时"
            return False, "请手动检查:没有返回"
        # 分析 std_out
        if task.data.get('OSType', '').lower() == 'windows':
            return self.parse_windows(task)
        if task.data.get('OSType', '').lower() == 'linux':
            return self.parse_linux(task)
        return False, "请手动检查:未知原因"

    def parse_linux(self, task: RuncmdTask) -> _Check:
        """针对Linux系统的检查逻辑"""
        return True, "未指定策略:默认合规"

    def parse_windows(self, task: RuncmdTask) -> _Check:
        """针对Windows系统的检查逻辑"""
        return True, "未指定策略:默认合规"


class Azure_Monitor_Report_Resource(object):

    fieldnames_base = ['名称', '类型', '资源组', '位置', '订阅']
    str_query_template = textwrap.dedent("""\
        resources
        | where type == "{}"
        | join kind=leftouter (
            Resourcecontainers
            | where type =~ "microsoft.resources/subscriptions"
            | project SubName=name, subscriptionId
        ) on subscriptionId
        | project-away subscriptionId1
        | project name, type, resourceGroup, location, SubName, subscriptionId
        """)

    resource_type_map = {
        "microsoft.analysisservices/servers": "Analysis Services",
        "microsoft.insights/components": "Application Insights",
        "microsoft.databricks/workspaces": "Azure Databricks 服务",
        "microsoft.containerservice/managedclusters": "Kubernetes 服务",
        "microsoft.operationalinsights/workspaces": "Log Analytics 工作区",
        "microsoft.powerbidedicated/capacities": "Power BI Embedded",
        "microsoft.network/loadbalancers": "负载均衡器",
        "microsoft.web/sites/kind=functionapp": "函数应用",
        "microsoft.machinelearningservices/workspaces": "机器学习",
        "microsoft.operationsmanagement/solutions": "解决方案",
        "microsoft.media/mediaservices/streamingendpoints": "流式处理终结点",
        "microsoft.logic/workflows": "逻辑应用",
        "microsoft.media/mediaservices": "媒体服务",
        "microsoft.datafactory/factories": "数据工厂(V2)",
        "microsoft.search/searchservices": "搜索服务",
        "microsoft.network/applicationgateways": "应用程序网关",
        "microsoft.web/sites/kind=app": "应用服务",
        "microsoft.web/sites/slots": "应用服务(槽)",
        "microsoft.web/serverfarms": "应用服务计划",
    }

    def __init__(self, resource_type: str, metrics: List[_Metric], *, resource_type_display: Optional[str] = None) -> None:
        self.resource_type = resource_type
        self.metrics = metrics
        self._resource_type_display = resource_type_display
        self._writer = None
        self._fieldnames = None

    @property
    def resource_type_display(self) -> str:
        if self._resource_type_display:
            return self._resource_type_display
        self._resource_type_display = self.resource_type_map.get(self.resource_type)
        if self._resource_type_display is None:
            self._resource_type_display = self.resource_type
        return self._resource_type_display

    @property
    def str_query(self) -> str:
        if self.resource_type == "microsoft.web/sites/kind=functionapp":
            # 函数应用的资源查询需要多个过滤条件
            return textwrap.dedent("""\
                resources
                | where type == "microsoft.web/sites"
                | where kind == 'functionapp'
                | join kind=leftouter (
                    Resourcecontainers
                    | where type =~ "microsoft.resources/subscriptions"
                    | project SubName=name, subscriptionId
                ) on subscriptionId
                | project-away subscriptionId1
                | project name, type, resourceGroup, location, SubName, subscriptionId
                """)
        if self.resource_type == "microsoft.web/sites/kind=app":
            # 应用服务的资源查询需要多个过滤条件
            return textwrap.dedent("""\
                resources
                | where type == "microsoft.web/sites"
                | where kind == 'app'
                | join kind=leftouter (
                    Resourcecontainers
                    | where type =~ "microsoft.resources/subscriptions"
                    | project SubName=name, subscriptionId
                ) on subscriptionId
                | project-away subscriptionId1
                | project name, type, resourceGroup, location, SubName, subscriptionId
                """)
        if self.resource_type == "microsoft.web/sites/slots":
            # 应用服务(槽)，name包含上层应用服务的name。
            # 调用接口时的resource_type里包包含上层应用服务的name；而resource_name只要槽本身的name。
            # 直接用资源的id就省事了。资源id应该才是通用逻辑，不过之前的逻辑也能用，就提供两种方式吧。判断逻辑就是是否有id字段
            return textwrap.dedent("""\
                resources
                | where type == "microsoft.web/sites/slots"
                | join kind=leftouter (
                    Resourcecontainers
                    | where type =~ "microsoft.resources/subscriptions"
                    | project SubName=name, subscriptionId
                ) on subscriptionId
                | project-away subscriptionId1
                | project id, name, type, resourceGroup, location, SubName, subscriptionId
                """)
        return self.str_query_template.format(self.resource_type)

    def get_query(self, client: Azure_Client, str_query: Optional[str] = None) -> QueryResponse:
        """查询资源"""
        str_query = self.str_query if str_query is None else str_query
        res = client.resource_graph_query_default(
            str_query,
            [sub.get('subscription_id') for sub in client.get_subscriptions()], 
            )
        return res

    @property
    def fieldnames(self):
        if self._fieldnames is None:
            self._fieldnames = self.fieldnames_base.copy()
            for metric in self.metrics:
                for field in metric.fields:
                    self._fieldnames.append(field.fieldname)
        return self._fieldnames

    @property
    def writer(self):
        """以csv格式输出数据，第一次时输出表头"""
        if self._writer is None:
            self._writer = csv.DictWriter(sys.stdout, fieldnames=self.fieldnames)
            self._writer.writeheader()
        return self._writer

    @staticmethod
    def unit_conversion(size: Union[int, float], unit: _Unit) -> str:
        """单位换算
        size: 原始数值
        unit.scale: 换算进制，如 1000, 1024
        unit.units: 单位名称，如 'B', 'KB', 'MB', 'GB', 'TB'
        unit.space: 数值和单位之间，默认一个空格
        unit.scale=1, unit.units=[单位名称符号]：只添加单位，不改变值
        """
        for unit_name in unit.units:
            if unit.scale < 1:
                if size > 1:
                    break
            else:
                if size < unit.scale:
                    break
            size /= unit.scale
        return f'{size:.2f}{unit.space}{unit_name}'

    def _average(self, key: str, data: List[Mapping[str, Union[str, int, float]]], unit: Optional[_Unit] = None) -> str:
        data_list = [ i.get(key) for i in data if key in i ]
        data_list = [ j for j in data_list if j]
        data_res = sum(data_list) / len(data_list) if data_list else 0
        if unit:
            data_res = self.unit_conversion(data_res, unit)
        return data_res

    def _sum(self, key: str, data: List[Mapping[str, Union[str, int, float]]], unit: Optional[_Unit] = None) -> str:
        data_list = [ i.get(key) for i in data if key in i ]
        data_res = sum(data_list) if data_list else 0
        if unit:
            data_res = self.unit_conversion(data_res, unit)
        return data_res

    def _maximum(self, key: str, data: List[Mapping[str, Union[str, int, float]]], unit: Optional[_Unit] = None) -> str:
        data_list = [ i.get(key) for i in data if key in i ]
        data_res = max(data_list) if data_list else 0
        if unit:
            data_res = self.unit_conversion(data_res, unit)
        return data_res

    def __call__(self, client: Azure_Client) -> None:
        # 负载均衡器，要检查SKU类型，逻辑单独处理
        if self.resource_type == "microsoft.network/loadbalancers":
            return self.call_loadbalancers(client)

        # 应用程序网关，要检查SKU类型，逻辑单独处理
        if self.resource_type == "microsoft.network/applicationgateways":
            return self.call_applicationgateways(client)

        res = self.get_query(client)
        for data in res.data:
            row = {}
            row['名称'] = data.get('name')
            row['类型'] = self.resource_type_display
            row['资源组'] = data.get('resourceGroup')
            row['位置'] = data.get('location')
            row['订阅'] = data.get('SubName')
            for metric in self.metrics:
                params = metric.params._asdict()
                if 'id' in data:
                    r = client.monitor_metric_resourceid(data.get('id'), params=params)
                else:
                    r = client.monitor_metric(data.get('subscriptionId'), data.get('resourceGroup'), data.get('type'), data.get('name'), params=params)
                if 'message' in r.json() or 'error' in r.json():
                    print(r.json())
                    return
                # timeseries 有可能为空数组，会导致下标越界
                # value_data = r.json()['value'][0]['timeseries'][0]['data']
                timeseries = r.json()['value'][0]['timeseries']
                for field in metric.fields:
                    if timeseries:
                        value_data = timeseries[0]['data']
                        func = getattr(self, '_' + field.func)
                        row[field.fieldname] = func(field.key, value_data, metric.unit)
                    else:
                        row[field.fieldname] = '无数据'
            self.call_hook(row)
            self.writer.writerow(row)

    def call_hook(self, row: Mapping[str, str]) -> None:
        """加工row的钩子函数"""
        pass

    def call_loadbalancers(self, client: Azure_Client) -> None:
        """负载均衡器
        sku.name: 就是Portal上的SKU类型。基本(Basic)无数据，标准(Standard)有数据。
        """
        str_query = textwrap.dedent("""\
            resources
            | where type == "{}"
            | join kind=leftouter (
                Resourcecontainers
                | where type =~ "microsoft.resources/subscriptions"
                | project SubName=name, subscriptionId
            ) on subscriptionId
            | project-away subscriptionId1
            | project name, type, resourceGroup, location, SubName, subscriptionId, sku.name
            """).format(self.resource_type)
        res = self.get_query(client, str_query)
        for data in res.data:
            row = {}
            row['名称'] = data.get('name')
            row['类型'] = self.resource_type_display
            row['资源组'] = data.get('resourceGroup')
            row['位置'] = data.get('location')
            row['订阅'] = data.get('SubName')
            is_basci = True if data.get('sku_name').lower() == 'basic' else False
            for metric in self.metrics:
                if not is_basci:
                    params = metric.params._asdict()
                    r = client.monitor_metric(data.get('subscriptionId'), data.get('resourceGroup'), data.get('type'), data.get('name'), params=params)
                    if 'message' in r.json():
                        print(r.json())
                        return
                    # timeseries 有可能为空数组，会导致下标越界
                    timeseries = r.json()['value'][0]['timeseries']
                for field in metric.fields:
                    if timeseries:
                        value_data = timeseries[0]['data']
                        if not is_basci:
                            func = getattr(self, '_' + field.func)
                            row[field.fieldname] = func(field.key, value_data, metric.unit)
                        else:
                            row[field.fieldname] = "basic无数值"
                    else:
                        row[field.fieldname] = '无数据'
            self.writer.writerow(row)
        
    def call_applicationgateways(self, client: Azure_Client) -> None:
        """应用程序网关
        properties.sku.tier: WAF_v2没有CpuUtilization指标。
        """
        str_query = textwrap.dedent("""\
            resources
            | where type == "{}"
            | join kind=leftouter (
                Resourcecontainers
                | where type =~ "microsoft.resources/subscriptions"
                | project SubName=name, subscriptionId
            ) on subscriptionId
            | project-away subscriptionId1
            | project name, type, resourceGroup, location, SubName, subscriptionId, sku=properties.sku.tier
            """).format(self.resource_type)
        res = self.get_query(client, str_query)
        for data in res.data:
            row = {}
            row['名称'] = data.get('name')
            row['类型'] = self.resource_type_display
            row['资源组'] = data.get('resourceGroup')
            row['位置'] = data.get('location')
            row['订阅'] = data.get('SubName')
            is_wafv2 = True if data.get('sku').lower() == 'waf_v2' else False
            is_standard_v2 = True if data.get('sku').lower() == 'standard_v2' else False
            for metric in self.metrics:
                if not is_wafv2 and not is_standard_v2:
                    params = metric.params._asdict()
                    r = client.monitor_metric(data.get('subscriptionId'), data.get('resourceGroup'), data.get('type'), data.get('name'), params=params)
                    if 'message' in r.json() or 'error' in r.json():
                        print(r.json())
                        return
                    # timeseries 有可能为空数组，会导致下标越界
                    # value_data = r.json()['value'][0]['timeseries'][0]['data']
                    # timeseries 有可能没有value这个Key
                    timeseries = r.json()['value'][0]['timeseries'] if 'value' in r.json() else None
                for field in metric.fields:
                    if is_wafv2:
                        row[field.fieldname] = "WAF_v2无指标"
                    elif is_standard_v2:
                        row[field.fieldname] = "Standard_v2无指标"
                    elif timeseries:
                        value_data = timeseries[0]['data']
                        if not is_wafv2:
                            func = getattr(self, '_' + field.func)
                            row[field.fieldname] = func(field.key, value_data, metric.unit)
                    else:
                        row[field.fieldname] = '无数据'
            self.writer.writerow(row)



